/*
 * Copyright (C) 2009 M. Hautle.
 * 
 * This file is part of TrackedBean.
 * 
 * TrackedBean is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option) any later version.
 * 
 * TrackedBean is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License along with TrackedBean. If not, see <http://www.gnu.org/licenses/>.
 */
package ch.trackedbean.binding.guicomponents.action;

import java.util.*;
import java.util.regex.*;
import javax.swing.*;

/**
 * Helper for populating a {@link JPopupMenu}.
 * 
 * @author M. Hautle
 */
public class PopupHelper {
	/** The name of the default group. */
	public static final String DEFAULT_GROUP = "zzzzzzzzzzzz";

	/** Regex representing an normal char in a menu path. */
	private static final String CHAR = "[^\\{\\}\\[\\]\\\\]";

	/** Regex representing an order instruction in a menu path. */
	private static final String ORDER = "(?:\\{(\\d{1,4})\\})";

	/** Regex representing an group instruction in a menu path. */
	private static final String GROUP = "(?:\\[(" + CHAR + "+?)" + ORDER + "?\\])";

	/** Regex representing a part of a menu path. */
	private static final String PATH_PART_EXPR = "(" + CHAR + "+?)?" + GROUP + "?" + ORDER + "?\\\\";

	/** Regex representing a menu path. */
	private static final String PATH_EXPR = "(" + CHAR + "+?" + GROUP + "?" + ORDER + "?\\\\)*(" + CHAR + "*?" + GROUP + "?" + ORDER + "?)";

	/** Pattern of {@link #PATH_EXPR}. */
	private static final Pattern PATH = Pattern.compile(PATH_EXPR);

	/** Pattern of {@link #PATH_PART_EXPR}. */
	private static final Pattern PATH_PART = Pattern.compile(PATH_PART_EXPR);

	/** The path parser of this helper. */
	protected final EntryPath pathParser = new EntryPath();

	/** The existing groups, the key is the group path. */
	protected final Map<String, ItemGroup> groups = new WeakHashMap<String, ItemGroup>();

	/** The existing sub menus, the key is the menu path. */
	protected final Map<String, JMenu> subMenus = new HashMap<String, JMenu>();

	/** List holding all groups in the right order. */
	protected final List<ItemGroup> order = new ArrayList<ItemGroup>();

	/** The popup menu to populate. */
	protected JPopupMenu popup;

	/**
	 * Checks if the given path is valid.
	 * 
	 * @param path The path to validate
	 * @return True if the given path is valid
	 */
	public static boolean validPath(String path) {
		return PATH.matcher(path).matches();
	}

	/**
	 * Removes all elements of the passed menu and calls then {@link #setPopup(JPopupMenu)}.
	 * 
	 * @param menu The menu
	 */
	public void prepareAndSetPopup(JPopupMenu menu) {
		menu.removeAll();
		setPopup(menu);
	}

	/**
	 * Sets the popup menu to populate<br>
	 * Consider using {@link #prepareAndSetPopup(JPopupMenu)}.
	 * 
	 * @param menu The menu
	 */
	public void setPopup(JPopupMenu menu) {
		// ensure that we start in a clean environment
		if (order.size() > 0)
			cleanUp();
		popup = menu;
	}

	/**
	 * Returns the popupmenu.
	 * 
	 * @return Returns the popup.
	 */
	public JPopupMenu getPopup() {
		return popup;
	}

	/**
	 * Cleans up the helper.<br>
	 * Please call this method after every processed {@link JPopupMenu}.
	 */
	public void cleanUp() {
		popup = null;
		// reset the groups
		for (ItemGroup g : order)
			g.reset();
		order.clear();
		subMenus.clear();
	}

	/**
	 * Adds the given action at the end of the given path.<br>
	 * The path contains 0-n elements with the following syntax: <code>SUBMENU[GROUP{GROUP_ORDER}?]?{SUBMENU_ORDER}?</code> the elements were seperadet by '\'<br>
	 * For the last path element the following syntax is also allowed:<code>[GROUP{GROUP_ORDER}?]?{ACTION_ORDER}?</code>
	 * 
	 * @param path The path (the expression gets only validated if assertions were activated!)
	 * @param a The action to add
	 */
	public void addAction(String path, Action a) {
		assert validPath(path) : "Invalid path: " + path;
		final JPopupMenu dst = getDestination(pathParser.parsePath(path));
		final int index = pathParser.getEntryIndex();
		final ItemGroup grp = getGroup(pathParser, index, dst);
		grp.addAction(pathParser.getSubMenuOrder(index), a, dst, getGroupStartIndex(grp));
	}

	/**
	 * Removes the {@link JSeparator}s at the end of the menu entries.
	 */
	public void removeEndSeperators() {
		if (popup == null)
			return;
		removeLastSeperator(popup);
		for (JMenu m : subMenus.values())
			removeLastSeperator(m.getPopupMenu());
	}

	/**
	 * Removes the last element of the given menu if it's a {@link JSeparator}.
	 * 
	 * @param menu The menu
	 */
	private void removeLastSeperator(JPopupMenu menu) {
		// remove the seperator of the last group
		final int index = menu.getComponentCount() - 1;
		if (index > -1 && menu.getComponent(index) instanceof JSeparator)
			menu.remove(index);
	}

	/**
	 * Returns the destination popup for the given path.<br>
	 * If it doesen't yet exists it will be created.
	 * 
	 * @param path The path
	 * @return The destination popup
	 */
	protected JPopupMenu getDestination(EntryPath path) {
		final int cnt = path.getEntryCount();
		// if it's a base path then return the base popup
		if (cnt == 0)
			return popup;
		// check if the menu allready exists
		JMenu menu = subMenus.get(path.getSubMenuPath(cnt - 1));
		if (menu != null)
			return menu.getPopupMenu();
		JPopupMenu popup = this.popup;
		// ensure that the whole path to our submenu exists
		for (int i = 0; i < cnt; i++)
			popup = getMenu(path, i, popup);
		return popup;
	}

	/**
	 * Returns the desired menu.<br>
	 * If it doesen't yet exists it will be created.
	 * 
	 * @param info The path info
	 * @param index The index of the desired menu in the path info
	 * @param parent The parent menu
	 * @return The desired menu
	 */
	protected JPopupMenu getMenu(EntryPath info, int index, JPopupMenu parent) {
		final String path = info.getSubMenuPath(index);
		JMenu menu = subMenus.get(path);
		if (menu != null)
			return menu.getPopupMenu();
		// if the menu doesen't yet exists then we create it
		subMenus.put(path, menu = new JMenu(info.getSubMenuName(index)));
		// and we add it to the parent by using it's group
		final ItemGroup grp = getGroup(info, index, parent);
		grp.addMenu(info.getSubMenuOrder(index), menu, parent, getGroupStartIndex(grp));
		return menu.getPopupMenu();
	}

	/**
	 * Returns the group for the given entry.<br>
	 * If the group does not yet exists it will be created.
	 * 
	 * @param info The path info
	 * @param index The index of the desired group in the path info
	 * @param parent The menu holding the group
	 * @return The group
	 */
	protected ItemGroup getGroup(EntryPath info, int index, JPopupMenu parent) {
		final String path = info.getGroupPath(index);
		ItemGroup grp = groups.get(path);
		if (grp == null) {
			grp = new ItemGroup(info.getGroupOrder(index), info.getGroupName(index), info.getGroupParent(index));
			groups.put(path, grp);
		}
		if (!grp.active) {
			grp.active = true;
			final int i = Collections.binarySearch(order, grp);
			order.add(i < 0 ? -i - 1 : i, grp);
			parent.insert(new JPopupMenu.Separator(), getGroupStartIndex(grp));
		}
		return grp;
	}

	/**
	 * Returns the group before the given group.
	 * 
	 * @param group The group
	 * @return The previous group or null if the given group is the first
	 */
	public ItemGroup getPreviousGroup(ItemGroup group) {
		ItemGroup previous = null;
		for (ItemGroup g : order) {
			if (g == group)
				break;
			if (g.parent == group.parent)
				previous = g;
		}
		return previous;
	}

	/**
	 * Computes the start index of the given group.
	 * 
	 * @param group The desired group
	 * @return The start index of the group
	 */
	public int getGroupStartIndex(ItemGroup group) {
		int index = 0;
		for (ItemGroup g : order) {
			if (g == group)
				break;
			if (g.parent.equals(group.parent))
				index += g.getCount();
		}
		return index;
	}

	/**
	 * Computes the end index of the given group (without end seperator).
	 * 
	 * @param group The desired group
	 * @return The end index of the group
	 */
	public int getGroupEndIndex(ItemGroup group) {
		return getGroupStartIndex(group) + group.getCount();
	}

	/**
	 * Object holding an parsed entry path.
	 * 
	 * @author M. Hautle
	 */
	protected static class EntryPath {
		/** The size used for the increase operations. */
		private static final int SIZE = 10;

		/** The entry for undefined orders. */
		private static final String UNDEF_ORDER = "9999";

		/** The list of the group names. */
		private String[] groups = new String[SIZE];

		/** The order index for each entry of {@link #groups}. */
		private String[] groupOrder = new String[SIZE];

		/** The list of the sub menu names. */
		private String[] subMenus = new String[SIZE];

		/** The number of entries in {@link #subMenus}. */
		private int subMenuCnt = 0;

		/** The order index for each entry of {@link #subMenus}. */
		private String[] subMenuOrder = new String[SIZE];

		/**
		 * Parses the given path and stores it values in this object.
		 * 
		 * @param path The path to parse
		 * @return A reference to this object
		 */
		protected EntryPath parsePath(String path) {
			final Matcher m = PATH_PART.matcher(path + "\\");
			final StringBuilder menuPath = new StringBuilder();
			// split up the path
			for (subMenuCnt = 0; m.find(); subMenuCnt++) {
				// ensure that the arrays were big enough
				if (subMenus.length == subMenuCnt)
					increaseSize();
				groups[subMenuCnt] = getPath(menuPath, m.group(2), true);
				groupOrder[subMenuCnt] = getOrder(m.group(3));
				subMenus[subMenuCnt] = getPath(menuPath, m.group(1), false);
				subMenuOrder[subMenuCnt] = getOrder(m.group(4));
			}
			// check if there are entry settings
			if (subMenus[subMenuCnt - 1] == null) {
				subMenuCnt--;
			} else {
				// otherwise the default values were set
				groups[subMenuCnt] = DEFAULT_GROUP;
				groupOrder[subMenuCnt] = UNDEF_ORDER;
				subMenuOrder[subMenuCnt] = UNDEF_ORDER;
			}
			return this;
		}

		/**
		 * Returns the number of entries (without the action entry itself).
		 * 
		 * @return The number of entries
		 */
		protected int getEntryCount() {
			return subMenuCnt;
		}

		/**
		 * Returns the index for the entry itself.
		 * 
		 * @return The index
		 */
		protected int getEntryIndex() {
			return subMenuCnt;
		}

		/**
		 * Retuns the submenu path of the submenu at the given index.<br>
		 * Use {@link #getEntryCount()} to get the number of the list backing this method.
		 * 
		 * @param index The index
		 * @return The path at the given index
		 */
		protected String getSubMenuPath(int index) {
			return subMenus[index];
		}

		/**
		 * Retuns the submenu name of the submenu at the given index.<br>
		 * Use {@link #getEntryCount()} to get the number of the list backing this method.
		 * 
		 * @param index The index
		 * @return The name at the given index
		 */
		protected String getSubMenuName(int index) {
			return subMenus[index].substring(subMenus[index].lastIndexOf('\\') + 1);
		}

		/**
		 * Retuns the submenu order of the submenu at the given index.<br>
		 * Use {@link #getEntryCount()} to get the number of the list backing this method.
		 * 
		 * @param index The index
		 * @return The order at the given index
		 */
		protected String getSubMenuOrder(int index) {
			return subMenuOrder[index];
		}

		/**
		 * Retuns the group path of the group at the given index.<br>
		 * Use {@link #getEntryCount()} to get the number of the list backing this method.
		 * 
		 * @param index The index
		 * @return The path at the given index
		 */
		protected String getGroupPath(int index) {
			return groups[index];
		}

		/**
		 * Retuns the group name of the group at the given index.<br>
		 * Use {@link #getEntryCount()} to get the number of the list backing this method.
		 * 
		 * @param index The index
		 * @return The name at the given index
		 */
		protected String getGroupName(int index) {
			return groups[index].substring(groups[index].lastIndexOf('\\') + 1);
		}

		/**
		 * Retuns the path to the parent of the group.
		 * 
		 * @param index The index of the group
		 * @return The path at the given index
		 */
		protected String getGroupParent(int index) {
			if (index == 0)
				return "";
			return subMenus[index - 1];
		}

		/**
		 * Retuns the group order of the group at the given index.<br>
		 * Use {@link #getEntryCount()} to get the number of the list backing this method.
		 * 
		 * @param index The index
		 * @return The order at the given index
		 */
		protected String getGroupOrder(int index) {
			return groupOrder[index];
		}

		/**
		 * Concatenates the given path with the given entry.
		 * 
		 * @param path The current path
		 * @param entry The entry to add to the path
		 * @param group If the path is for an group
		 * @return The new path or null if null was passed and it's not a group
		 */
		protected String getPath(StringBuilder path, String entry, boolean group) {
			if (entry == null) {
				if (!group)
					return null;
				entry = DEFAULT_GROUP;
			}
			final int len = path.length();
			final String res = path.append('\\').append(entry).toString();
			// reset the stringbuilder to the state it was passed
			if (group)
				path.setLength(len);
			return res;
		}

		/**
		 * Returns the corresponding order number for an given number.
		 * 
		 * @param nr The number or null
		 * @return The corresponding order number
		 */
		protected String getOrder(String nr) {
			if (nr == null)
				return UNDEF_ORDER;
			final StringBuilder number = new StringBuilder();
			// ensure that the number has the same length as the undefined order number
			for (int i = 0, cnt = UNDEF_ORDER.length() - nr.length(); i < cnt; i++)
				number.append('0');
			number.append(nr);
			return number.toString();
		}

		/**
		 * Increases the size of all arrays by {@link #SIZE}.
		 */
		protected void increaseSize() {
			groups = increseSize(groups);
			groupOrder = increseSize(groupOrder);
			subMenus = increseSize(subMenus);
			subMenuOrder = increseSize(subMenuOrder);
		}

		/**
		 * Increases the size of the given array.
		 * 
		 * @param src String array
		 * @return The given array increased by {@link #SIZE}.
		 */
		protected String[] increseSize(String[] src) {
			final String[] newArray = new String[src.length + SIZE];
			System.arraycopy(src, 0, newArray, 0, src.length);
			return newArray;
		}
	}

	/**
	 * Object describing a group of items in the menu.<br>
	 * The name of the group is used to order a list of {@link ItemGroup}s.
	 * 
	 * @author M. Hautle
	 */
	protected static class ItemGroup implements Comparable<ItemGroup> {
		/** The name of the item group */
		protected final String name;

		/** The name of the parent (null for root groups). */
		protected final String parent;

		/** List containing the item names of this group (used to determinate the right insert index for an new entry) */
		protected final List<String> items = new ArrayList<String>(1);

		/** Flag indicating if this group is currently used. */
		protected boolean active = false;

		/**
		 * Default constructor.
		 * 
		 * @param order The order key of the group
		 * @param name The name of the group
		 * @param parent The parent group
		 */
		protected ItemGroup(String order, String name, String parent) {
			this.parent = parent;
			this.name = new StringBuilder(order.length() + name.length()).append(order).append(name).toString();
		}

		/**
		 * Adds the given action on the given menu using the information of this group for the insertion index.
		 * 
		 * @param order The order key of the action
		 * @param a The action
		 * @param menu The menu were to add it
		 * @param offset The offset in the menu of the group itself
		 */
		protected void addAction(String order, Action a, JPopupMenu menu, int offset) {
			final String name = (String) a.getValue(Action.NAME);
			final String key = new StringBuilder(order.length() + name.length()).append(order).append(name).toString();
			int index = Collections.binarySearch(items, key);
			if (index < 0)
				index = -index - 1;
			items.add(index, key);
			menu.insert(a, index + offset);
		}

		/**
		 * Adds the given submenu on the given menu using the information of this group for the insertion index.
		 * 
		 * @param order The order key of the submenu
		 * @param subMenu The submenu
		 * @param menu The menu were to add it
		 * @param offset The offset in the menu of the group itself
		 */
		protected void addMenu(String order, JMenu subMenu, JPopupMenu menu, int offset) {
			final String name = subMenu.getText();
			final String key = new StringBuilder(order.length() + name.length()).append(order).append(name).toString();
			int index = Collections.binarySearch(items, key);
			if (index < 0)
				index = -index - 1;
			items.add(index, key);
			menu.add(subMenu, index + offset);
		}

		/**
		 * Resets the group information.
		 */
		protected void reset() {
			active = false;
			items.clear();
		}

		/**
		 * Returns the number of items +1 .
		 * 
		 * @return The number of items +1
		 */
		protected int getCount() {
			return items.size() + 1;
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public int compareTo(ItemGroup o) {
			return name.compareTo(o.name);
		}
	}
}
